День 2 Летней школы Julia 2025
Для того чтобы глубже понимать, как устроены числа, строки, кортежи, вектора, словари и многое другое в Julia - необходимо разобраться с классификацией типов в Julia.
Сначала мы дадим определения типов, а потом на примерах рассмотрим, что же они
из себя представляют.
Типы в Julia
-
Примитивный тип: тип, определяемый с помощью ключевого слова
primitive type. Объекты примитивного типа имеют заданный фиксированный размер памяти, указанный в определении типа. 📝Int64,Bool,Char -
Составной тип: тип, определяемый с помощью ключевого слова
struct. Составные типы состоят из нуля или более полей, ссылающихся на другие объекты (примитивного или составного типа).📝Complex,Rational(поляre, imиnum, den, соответственно),Tuple -
Конкретный тип: примитивный или составной тип
-
Абстрактный тип: тип, определяемый с помощью ключевого слова
abstract type. Абстрактные типы не имеют полей, и объекты не могут быть созданы (инстанцированы) на их основе. Кроме того, они не могут быть объявлены дочерними по отношению к конкретному типу. Также к абстрактным типам относятся не конкретные типы.📝Number,AbstractFloat -
Изменяемый тип: составной тип, определяемый с помощью ключевого слова
mutable struct. Изменяемые типы могут связывать свои поля с другими объектами, отличными от тех, с которыми они были связаны во время инициализации.📝String,Dict -
Неизменяемый тип: все типы, кроме тех, которые определяются с помощью
mutable struct. -
Параметрический тип: семейство (изменяемых или неизменяемых) составных или абстрактных типов с одинаковыми именами полей и названием типа без учёта типов параметров. Определённый тип затем однозначно идентифицируется по имени параметрического типа и типу (типам) параметра (параметров). 📝
Rational{Int8}(1,2), см. нижеAbstractArray{T,N},Array{T,N} -
Исходные типы: тип, определение которого содержится в Julia Base или в стандартной библиотеке Julia
-
Битовый тип: примитивный или неизменяемый составной тип, все поля которого являются битовыми типами
-
Синглтон: объект, созданный на основе составного типа, состоящего из нуля полей. 📝
nothing,missing -
Контейнер: составной тип (не обязательно изменяемый), предназначенный для ссылки на переменное количество объектов и предоставляющий методы для доступа, перебора и, в конечном итоге, изменения ссылок на другие объекты.
Примитивный тип
Несмотря на то, что в документации не рекомендуется использовать конструкция primitive type, я предлагаю начать знакомство с типами именно с примитивных.
Это сделано потому, что здесь мы опустимся на самый низкий уровень, где будет видно то, как в конечном итоге будут представлены данные в памяти.
В качестве примера введём "защищённый от помех из космоса" Bool, который заполняет все возможные доступные битовые ячейки либо 0 либо 1.
При создании примитивного типа необходимо явно указывать сколько бит необходимо для хранения этого типа. (В нашем случае 8).
Укажем конструктор для этого типа, если в качестве аргумента передаётся Int64-число.
Base.show переопределяем для того, чтобы можно было вывести наше число на экран.
primitive type FilledBool 8 end
function FilledBool(x::Int)
if iszero(x)
reinterpret(FilledBool,0b00000000)
elseif x == 1
reinterpret(FilledBool,0b11111111)
else
error("В качестве параметров допустимы только 0 и 1")
end
end
Base.show(io :: IO, x :: FilledBool) = print(io, bitstring(x))
@show tr = FilledBool(1)
@show fls = FilledBool(0)
println("Regular Bool true: ", bitstring(true))
Проверим, является ли наш тип битовым
Да, он таковым является, так как все непараметрические примитивные типы являются битовыми.
isbitstype(FilledBool)
В документации говорится, что вместо того, чтобы создавать собственные примитивные типы - лучше делать обёртку над ними в в виде составного типа.
Давайте же познакомимся с ним поближе!
Составной тип
Неизменяемый составной тип
Важно понимать, что составной тип может состоять как из нескольких полей, так и из одного или нуля полей.
В отличие от многих других языков программирования, где с объектом связаны поля и методы, к составному типу в Julia привязаны только поля и его конструктор.
Но мы пока сосредоточимся на типах.
Пускай у нас есть тип "Гора". Мы указываем 2 характеристики объектов этого типа:
- год покорения (год может быть положительным или отрицательным)
- высота горы (предположим, что все горы выше уровня моря)
В неизменяемых типах после их создания нельзя поменять поля.
struct Mountain
first_ascent_year::Int16
height::UInt16
end
Everest = Mountain(1953,8848)
Int(Everest.height)
Everest.height = 9000 # нельзя менять значения полей MountPetin
Чтобы подробнее посмотреть на то, как устроена структура, можно воспользоваться функцией dump:
dump(Everest)
Каждый тип элемента неизменяемой структуры Mountain является битовым, поэтому и тип Mountain является битовым
@show sizeof(Mountain) # 2 поля по 2 байта = 4
isbitstype(Mountain)
Давайте попробуем переопределить нашу структуру и
добавим булево поле "Является ли вулканом".
struct Mountain
first_ascent_year::Int16
height::UInt16
isvolcano::Bool
end
Как видим, вывелась ошибка, указывающая на то, что
Неизменяемые типы нельзя переопределять ("переобъявить") без использования специальных пакетов.
Также отметим, что по-умолчанию в Julia нельзя присваивать значение по-умолчанию для полей.
Но если это требуется, то можно использовать пакет Parameters.jl
struct Point
x::Int = 0
y::Int = 0
end
Рассмотрим случай, когда полями неизменяемой структуры является не битовый тип.
Строка хранится не как массив элементов Char'ов, а как указатель на массив Char'ов.
Поэтому размер структуры - 8 байт (размер указателя), а размер строки - 6 байт.
struct City
name::String
end
Moscow = City("Moscow")
Moscow.name
@show sizeof(Moscow)
@show sizeof(Moscow.name)
@show Base.summarysize(Moscow);
В качестве альтернативы можно использовать статические строки.
Для этого подключим соответствующую библиотеку и, указав перед строкой Moscow
слово static
using StaticStrings
struct StaticCity
name::StaticString{6}
end
Moscow = StaticCity(static"Moscow") # дополняется \0 до 10
@show sizeof(Moscow)
@show sizeof(Moscow.name)
@show Base.summarysize(Moscow);
Несмотря на то, что мы не можем изменить строку, этот тип не является битовым.
Т.е. важно понимать отличие между неизменяемым и битовым типами.
Обе структуры содержат неизменяемые строки. Но только во втором случае мы имеем битовый тип.
@show isbitstype(City)
@show isbitstype(StaticCity);
Отметим одну интересную особенность в работах современных компьютеров:
они работают эффективнее, если структуры данных являются выравненными.
Красным отметим занимаемые полезной информацией поля, а зелёными - условный мусор в памяти компьютера.
переменная типа Bool располагается первой.
вторая переменная, занимающая 2 байта - должна располагаться с чётной позиции, учитывая индексацию с 0. А 8-байтовое значение должно начинаться с позиции, кратной 8.
struct aligned
один::Bool
два::Int16
восемь::Float64
end
# 1 + 2 + 8 = 11
# 🟥🟩 🟥🟥 🟩🟩🟩🟩 🟥🟥🟥🟥🟥🟥🟥🟥
sizeof(aligned(true,'a',1.5))
Хочется отдельно отметить, что неизменяемый тип может иметь поля изменяемого типа.
В качестве аналогии:
Пускай у нас есть верёвочка, к которой привязан красный воздушный шарик, который мы можем изменять: растягивать, надувать, наполнять водой.
Но мы не можем оторвать верёвочку и прикрепить к ней зелёный шарик.
Давайте убедимся в этом на примере структуры ученика. У него есть 2 поля неизменяемого типа
- имя и класс. А вот оценки в течение года будут накапливаться. Поэтому они у нас будут типа вектор.
struct Student
name::String
grade::UInt8 # класс
grades::Vector{Int} # оценки
end
Создадим первоклассника и отличника Алекса.
Теперь мы уже знаем, почему размер нашей структуры равен 24 байтам.
Alex = Student("Alex", 1, [5,5,5])
@show sizeof(Alex); # 8 + 1 + 8 = 17 => 24 округление до x % 8 == 0
Давайте теперь проверим, что мы можем добавлять элементы.
push!(Alex.grades,4)
Alex.grades
Как можем заметить - мы меняем элементы вектора, но не сам вектор.
А если же мы захотим поменять не элементы вектора,а вектор, то произойдёт ошибка.
Alex.grades = [1, 2, 3] # здесь же мы заменяем, а не изменяем вектор
Изменяемый тип
В случае же изменяемого типа мы можем менять поля.
Для этого вместо struct нужно прописать mutable struct.
mutable struct MutableStudent
const name::String
grade::UInt8 # класс
grades::Vector{Int} # оценки
end
Peter = MutableStudent("Peter", 1, [5,5,5])
Peter.grade = 2
Но и в изменяемых структурах есть возможность делать некоторые поля неизменяемыми (константными). Для этого перед именем поля, которое мы хотим оставить константным, нужно прописать слово const.
В этом случае, несмотря на то, что структура - изменяемая, значение этого поле не получится поменять.
Peter.name = "Alex"
Можно заметить, что теперь мы можем заменять вектор на другой:
@show Peter.grades = [2,2,2]
Отличие неизменяемой struct от mutable struct с константными полями.
Несмотря на то, что поля неизменяемой структуры и константные поля изменяемой структуры нельзя менять, есть существенная разница между объектами таких типов с одинаковыми полями.
В случае с неизменяемым типом - объекты с одинаковыми полями это буквально один и тот же объект, так как все объекты с одинаковыми полями будут располагаться по одному адресу.
В случае с mutable struct каждый из объектов с одинаковыми константными полями будут располагаться по своему уникальному адресу.
struct Immutable
a::Int32
b::Int32
end
mutable struct ConstMutable
const a::Int32
const b::Int32
end
im_obj_1 = Immutable(1,2)
im_obj_2 = Immutable(1,2)
const_mut_obj_1 = ConstMutable(1,2)
const_mut_obj_2 = ConstMutable(1,2)
# === означает равенство и значений и адресов в памяти
@show im_obj_1 === im_obj_2
@show const_mut_obj_1 === const_mut_obj_2
Неизменяемые структуры могут быть не такие удобные в плане интерфейса их использования.
Но их преимуществом является размещение "на стеке". В то время как изменяемые структуры обычно хранятся "в куче".
println(@allocations (a = Immutable(3,4); b = Immutable(3,4)))
println(@allocations (a = ConstMutable(3,4); b = ConstMutable(3,4)))
Однако к этому утверждению не нужно относиться буквально.
Так, например, компилятор может провести оптимизации и не выделять память для изменяемых структур внутри функции, которая будет возвращать число, а не изменяемую структуру:
function foo(x,y)
obj1 = Immutable(x,y)
obj2 = Immutable(y,x)
c = obj1.a + obj2.b
end
function bar(x,y)
obj1 = ConstMutable(x,y)
obj2 = ConstMutable(y,x)
c = obj1.a + obj2.b
end
println(@allocations foo(1,2))
println(@allocations bar(1,2))
Абстрактный тип
В отличие от многих других языков, в Julia не предусмотрено наследование
конкретных типов.
То есть нельзя создать структуру Animal, у которой было бы поле
средняя продолжительность жизни
А от неё наследовалась бы структура
Млекопитающее, в которой бы при помощи нашего наследования неявно бы имелось поле продолжительности жизни, и мы бы добавили поле период кормления молоком.
Такое наследование недопустимо в Julia.
Но как же тогда между собой связан Float, целые, рациональные и комплексные числа?
Для того чтобы это узнать - познакомимся с абстрактными типами.
Для чего нужны абстрактные типы?
Абстрактные типы нужны для того чтобы:
- группировать конкретные типы
- задавать интерфейсы для функций
- управлять областью создания других классов при помощи параметризации (см. ниже)
Группирование конкретных типов
Благодаря абстрактным типам можно организовывать иерархии типов.
Рассмотрим классический и наиболее понятный тип - Number.
Используя A <: B Мы можем указывать или проверять то, что тип A является подтипом B
Int8 <: Integer || Int16 <: Integer
Чтобы посмотреть все подтипы интересующего нас типа - воспользуемся функцией subtypes.
subtypes(Signed)
Также можно работать и в обратную сторону:
B :> A показывает, что B является надтипом A
А функция supertypes возвращает упорядоченный слева-направо по возрастанию кортеж надтипов
supertypes(Int8)
Но более визуально приятным расширением является пакет AbstractTrees, позволяющий нам получить дерево абстрактных типов. Рассмотрим дерево абстрактных типов для чисел.
using AbstractTrees
AbstractTrees.children(t::Type) = subtypes(t)
print_tree(Number)
Абстрактные типы и множественная диспетчеризация
Абстрактные типы и полиморфизм тесно связаны между собой.
Поэтому давайте узнаем, как реализован полиморфизм в Julia.
Полиморфизм
Полиморфизм в языках программирования и теории типов — способность функции обрабатывать данные разных типов.
Принято разделять полиморфизм на специализированный (ad-hoc) и параметрический.
Но проще понять, что это такое, на конкретных примерах
Перегрузка и приведение типов (ad-hoc) полиморфизм
И начнём мы с ad-hoc полиморфизма
Перегрузка функций - это возможность использовать одноимённые функции.
То есть одним и тем же словом length мы можем узнавать длину строки, длину диапазона и длину массива.
Хотя строки и массивы это разные понятия.
@show length("123")
@show sizeof(1:1000)
@show sizeof([1:1000;])
@show length(1:1000)
@show length([1:1000;])
Это кажется чем-то естественным, но, например, в языке Си нет встроенной удобной перегрузки.
И в нём приходится использовать string_length, array_length и прочие названия.
Второй подкатегорией ad-hoc полиморфизма является приведение типов.
Приведение типов бывает явным и неявным. В Julia указываются правила, по которым происходит преобразование.
Их можно поменять (но лучше этого не делать) или задать свои собственные, для заданных пользователем типов.
Преобразование и продвижение типов
Функция parse позволяет получить число из строки.
Преобразование позволяет представить
parse(Int,"789")
строка дальше разбивается на символы
parse(Int8,'7')
А из символа получаем число, вычитая 0.
'7' - '0' # Int64
В конце работы parse для символов происходит вызов функции convert, которая конвертирует число в тот тип, который мы хотим вернуть.
convert(Int8, '7' - '0') # convert(Int8, Int64)
Если же мы напрямую вызовем convert(Int8,'7'), то получим число 55 - означающее позицию символа 7 в ASCII таблице.
convert(Int8,'7')
Преобразование должно обладать обратимостью. То есть из a можно получить b и из b получить a.
convert(Char,55)
Поэтому когда на прошлом занятии мы писали i::Int = 3. и заменяя его на 3.5 получали ошибку, то это как раз была ошибка конвертации.
Из 3 можно однозначно получить 3. и наоборот. И если из 3.5 ещё как-то можно получить 3, применив какое-то из округлений, то вот из 3 получить 3.5 уже не получится, так как этой информации нет в целочисленной 3.
i::Int = 3.
i
Убедимся в том, что в нашем коде происходило преобразование, вызывав Meta.@lower, не вдаваясь в подробности, что это такое.
Meta.@lower i::Int = 1.
Продвижение типов
Теперь перейдём к продвижению типов.
Продвижение типа - это процесс, когда типы приводятся к одному общему типу, как бы "наибольшему" одному из двух.
Мы сами можем задать правила продвижения, какой тип считать более общим.
max(Int, Float) -> convert(Float, Int) -> max(Float, Float)
max(8, 4.5)
Это является адекватным ходом.
Проще привести всё к одному типу, для которого уже известны правила сравнения,
чем пытаться придумать, как эффективно сравнить Int и Float.
bitstring(8) |> println
bitstring(4.5) |> println
На этом мы завершим отступление про ad-hoc полиморфизм и поговорим про параметрический полиморфизм.
Отвлечёмся от чисел.
Пускай у нас имеется тип Домашнее животное.
И от него наследуются два неизменяемых составных типа (struct) - кошка и собака.
Определим функцию "сталкивается" и "приветствует".
Внутри функции сталкивается мы определяем способ, что делают два животных, когда они встречаются.
Их поведение должно зависеть от типов встречающихся животных.
abstract type Pet end
struct Dog <: Pet; name::String end
struct Cat <: Pet; name::String end
function encounter(a::Pet, b::Pet)
verb = meets(a, b)
println("$(a.name) встречает $(b.name) и $verb.")
end
meets(a::Dog, b::Dog) = "нюхает"
meets(a::Dog, b::Cat) = "гонится"
meets(a::Cat, b::Dog) = "шипит"
meets(a::Cat, b::Cat) = "мурлыкает"
rex = Dog("Рекс")
muhtar = Dog("Мухтар")
grey_cat = Cat("Матроскин"git@git.engee.com:community/models/nkapyrin/julia_summer_school_02.git)
black_cat = Cat("Бегемот")
encounter(rex, muhtar)
encounter(rex, grey_cat)
encounter(black_cat, muhtar)
encounter(grey_cat, black_cat)
Удобство состоит в том, что мы можем не уточнять под каждое животное, как оно
здоровается с другим, а сделать общий интерфейс "приветствия" для животных.
Это связано с тем, что мы определили функцию "сталкиваются" как зависящей от общего
для всех домашних животных абстрактного типа Pets.
meets(a::Pet, b::Pet) = "здоровается"
struct Cow <: Pet; name::String end
encounter(rex,Cow("Бурёнка"))
encounter(grey_cat,Cow("Бурёнка"))
Не во всех языках это работает так удобно.
Одиночная диспетчеризация в C++
#include <iostream>
// Базовый класс
class Animal {
public:
// Виртуальная функция
virtual void makeSound() const {
std::cout << "Some generic animal sound\n";
}
};
// Производный класс 1
class Dog : public Animal {
public:
// Переопределение виртуальной функции
void makeSound() const override {
std::cout << "Woof!\n";
}
};
// Производный класс 2
class Cat : public Animal {
public:
// Переопределение виртуальной функции
void makeSound() const override {
std::cout << "Meow!\n";
}
};
// Функция, использующая одиночную диспетчеризацию
void interact(const Animal& animal) {
animal.makeSound(); // Вызов метода зависит от типа объекта animal
}
int main() {
Dog dog;
Cat cat;
interact(dog); // Выведет: Woof!
interact(cat); // Выведет: Meow!
return 0;
}
Отсутствие множественной диспетчеризации в С++
#include <iostream>
#include <memory>
class Pet { };
class Cat : public Pet { };
class Dog : public Pet { };
class Foo
{
public:
virtual void meet(Pet* arg1, Pet* arg2) { std::cout << "Здоровается\n"; }
virtual void meet(Cat* arg1, Cat* arg2) { std::cout << "Мурлыкает\n"; }
virtual void meet(Dog* arg1, Cat* arg2) { std::cout << "Гонится\n"; }
virtual void meet(Cat* arg1, Dog* arg2) { std::cout <<"Шипит\n"; }
virtual void meet(Dog* arg1, Dog* arg2) { std::cout << "Нюхает\n"; }
};
void encounter(Pet* arg1, Pet* arg2)
{
std::unique_ptr<Foo> pFoo(new Foo);
pFoo->meet(arg1, arg2);
}
int main()
{
Pet* arg1 = new Cat();
Pet* arg2 = new Dog();
// использует только одиночную диспетчеризацию, будет выведено "Pet,Pet"
encounter(arg1, arg2);
return 0;
}
Это является одним из ключевых отличий Julia от большинства других языков.
Многим языкам сложно создавать функции, которые бы корректно работали исходя из
набора разных типов многих переменных. В python, например, приходится подключать отдельный пакет для этого.
А Julia это под силу, так как это было заложено при создании языка.
Параметрические типы
Параметрические могут быть как составные, так и абстрактные и даже примитивные типы.
Начнём с более очевидной разновидности:
Параметрические составные типы
могут быть полезны тогда, когда важно сохранение логики структуры, но тип полей объектов может меняться.
Так, например, можно догадаться, что из себя представляет комплексное число:
struct Complex{T<:Real} <: Number
re::T
im::T
end
complex_int8 = Int8(1) + Int8(2)im
@show typeof(complex_int8)
sizeof(complex_int8)
complex_float64 = 1.5+2im
@show typeof(complex_float64)
sizeof(complex_float64)
Как видим,в зависимости от того, какие параметры мы передавали, получаются объекты разных типов.
Они занимают разное количество памяти и могут работать по-разному.
Параметризованные абстрактные типы
Примером параметризованного абстрактного типа может служить AbstractDict
abstract type AbstractDict{K,V} end
Словарь же в свою очередь является:
mutable struct Dict{K,V} <: AbstractDict{K,V}
slots::Memory{UInt8}
keys::Memory{K}
vals::Memory{V}
...
...
end
НАКОНЕЦ, МАССИВЫ!
И только теперь, дойдя до параметрический абстрактных типов, мы можем понять, что такое массивы.
Посмотрим, что из себя представляют
[1,2,3]
[1 2 3;
4 5 6;
7 8 9;]
и rand(3,3,3)
Всё дело в определении этого типа:
abstract type AbstractArray{T,N} end
N здесь можно было бы обозначить как
abstract type AbstractArray{T,N<:Unsigned} end
Array <: DenseArray <: AbstractArray
Array{Int8,1}(undef,4)
Array{Int8,2}(undef,2,3)
Array{Int8,3}(undef,3,3,3)
И вот ответ на то, почему range в Julia поддерживает интерфейс массива:
1:0.5:1000 isa StepRangeLen <: AbstractArray
Т.е параметрический тип схож на шаблоны в C++.
Но важно понимать особенности типов в Julia
Параметрические типы в Julia являются инвариатными
Vector{Int} <: Vector{Real}
Vector{Int} <: Vector{<:Real}
Vector{Complex} <: Vector{<:Real}
Vector{Complex} <: Vector
И как же это всё применяется ?
Множественная диспетчеризация обладает следующим приоритетом:
- Конкретный тип
- Абстрактный тип
- Параметризованные тип
Тут подразумевается то, что вам нужно при помощи оператора <: посмотреть, кто ближе к конкретному типу
Complex{Int} <: Complex{<:Integer} <: Complex{<:Real} <: Complex
Но к тому порядку, что указан сверху, не нужно относиться буквально. Он лишь призван показать, что в функциях ниже
-
foo(x::Real) = "real"
-
foo(x::T) where T <: Integer = "parametric integer"
-
foo(x::Integer) = "integer"
-
foo(x::Int64) = "Int64"
foo(1)
при вызове foo(1) третий вариант приоритетнее второго. Но при этом абстрактный Real менее приоритетен, чем параметрический Integer, так как .
1 < 2 < 3 < 4
Закончим мы тем, что посмотрим на то, как устроена функция наибольшего общего кратного.